🎖️GitЯра🎖️
Commit 01cd54907d62cd90913d3d071b6bc240cae365ef
Parents : 8c7ad68
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-26T08:32:07-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-26T08:32:07-05:00
chore: harden untrusted-input handling and diagnostic output (#6441)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Changes
45 files changed, 1273 insertions(+), 132 deletions(-)
Diff
diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 3d1596c22b..33ae74fb9b 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -313,6 +313,7 @@ debug_log_api_enabled
debug_logcat_empty
debug_logcat_refresh
debug_logs_export
+debug_logs_export_warning
debug_logs_exported
debug_panel
debug_search_clear
diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
index 9c1a62a383..4445944aca 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -99,6 +99,8 @@ import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.geofence.toGeofence
import org.meshtastic.core.model.isLocked
import org.meshtastic.core.model.isModifiableBy
+import org.meshtastic.core.model.util.toCodePointString
+import org.meshtastic.core.model.util.waypointIconOrDefault
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.calculating
import org.meshtastic.core.resources.cancel
@@ -525,7 +527,8 @@ fun MapView(
val lock = if (pt.isLocked) "\uD83D\uDD12" else ""
val time = DateFormatter.formatDateTime(waypoint.time)
val label = pt.name + " " + formatAgo((waypoint.time / 1000).toInt(), unknownText, nowText)
- val emoji = String(Character.toChars(if (pt.icon == 0) 128205 else pt.icon))
+ // pt.icon is untrusted input; toCodePointString substitutes a fallback rather than throwing.
+ val emoji = pt.icon.waypointIconOrDefault().toCodePointString()
val now = nowMillis
val expireTimeMillis = pt.expire * 1000L
val expireTimeStr =
@@ -947,7 +950,7 @@ fun MapView(
val newId = if (waypoint.id == 0) mapViewModel.generatePacketId() else waypoint.id
val newName = if (waypoint.name.isNullOrEmpty()) "Dropped Pin" else waypoint.name
val newExpire = if (waypoint.expire == 0) Int.MAX_VALUE else waypoint.expire
- val newIcon = if (waypoint.icon == 0) 128205 else waypoint.icon
+ val newIcon = waypoint.icon.waypointIconOrDefault()
// locked_to is already resolved by the editor (our node number when locked, 0 when not).
mapViewModel.sendWaypoint(waypoint.copy(id = newId, name = newName, expire = newExpire, icon = newIcon))
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 62ca9149d5..8873734007 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -129,10 +129,13 @@ import org.meshtastic.core.model.isLocked
import org.meshtastic.core.model.isModifiableBy
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
+import org.meshtastic.core.model.util.isValidCodePoint
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.mpsToKmph
import org.meshtastic.core.model.util.mpsToMph
+import org.meshtastic.core.model.util.toCodePointString
import org.meshtastic.core.model.util.toString
+import org.meshtastic.core.model.util.waypointIconOrDefault
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alt
import org.meshtastic.core.resources.cancel
@@ -1540,11 +1543,12 @@ private fun Int.withAlpha(opacity: Float): Int = AndroidColor.argb(
// region --- Utilities ---
-internal fun convertIntToEmoji(unicodeCodePoint: Int): String = try {
- String(Character.toChars(unicodeCodePoint))
-} catch (e: IllegalArgumentException) {
- Logger.w(e) { "Invalid unicode code point: $unicodeCodePoint" }
- "\uD83D\uDCCD"
+internal fun convertIntToEmoji(unicodeCodePoint: Int): String {
+ if (!unicodeCodePoint.isValidCodePoint()) {
+ Logger.w { "Invalid unicode code point: $unicodeCodePoint" }
+ }
+ // waypointIconOrDefault before rendering: 0 is a valid code point, so it would otherwise render as U+0000.
+ return unicodeCodePoint.waypointIconOrDefault().toCodePointString()
}
/** Converts protobuf [Position] integer coordinates to a Google Maps [LatLng]. */
diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
index 50a6d0007d..4abe8c5bc9 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/component/WaypointMarkers.kt
@@ -37,6 +37,7 @@ import org.meshtastic.core.model.geofence.toGeofence
import org.meshtastic.core.model.isLocked
import org.meshtastic.core.model.isModifiableBy
import org.meshtastic.core.model.util.GeoConstants.DEG_D
+import org.meshtastic.core.model.util.waypointIconOrDefault
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.geofence
import org.meshtastic.core.resources.locked
@@ -71,7 +72,7 @@ fun WaypointMarkers(
}
}
- val iconCodePoint = if (waypoint.icon == 0) PUSHPIN else waypoint.icon
+ val iconCodePoint = waypoint.icon.waypointIconOrDefault()
val emojiText = convertIntToEmoji(iconCodePoint)
val icon =
rememberComposeBitmapDescriptor(iconCodePoint) {
@@ -115,5 +116,4 @@ fun WaypointMarkers(
}
}
-private const val PUSHPIN = 0x1F4CD // Unicode for Round Pushpin
private const val LOCK = 0x1F512 // Unicode for Lock
diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml
index 999c0e7bf4..45cd2a4918 100644
--- a/androidApp/src/main/AndroidManifest.xml
+++ b/androidApp/src/main/AndroidManifest.xml
@@ -316,11 +316,21 @@
android:resource="@xml/widget_local_stats_info" />
</receiver>
- <!-- allow for plugin discovery -->
+ <!--
+ ATAK plugin-discovery marker. The action is what ATAK looks for, so the filter stays exported; it now
+ resolves to a real no-op activity because the name used to be com.atakmap.app.component, a class not
+ present in this APK, so anything that launched it crashed the app on instantiation.
+
+ NOT VERIFIED AGAINST A REAL ATAK INSTALL: if any ATAK version keys discovery off the activity's class name
+ rather than off the action, this rename silently breaks it. Confirm on-device before relying on TAK
+ interop. Nothing else in this repo depends on the class name.
+ -->
<activity
- android:name="com.atakmap.app.component"
+ android:name=".AtakPluginDiscoveryActivity"
+ android:excludeFromRecents="true"
android:exported="true"
- tools:ignore="MissingClass">
+ android:noHistory="true"
+ android:theme="@android:style/Theme.NoDisplay">
<intent-filter android:label="@string/app_name">
<action android:name="com.atakmap.app.component" />
</intent-filter>
diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/AtakPluginDiscoveryActivity.kt b/androidApp/src/main/kotlin/org/meshtastic/app/AtakPluginDiscoveryActivity.kt
new file mode 100644
index 0000000000..85ceb39600
--- /dev/null
+++ b/androidApp/src/main/kotlin/org/meshtastic/app/AtakPluginDiscoveryActivity.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.app
+
+import android.app.Activity
+import android.os.Bundle
+
+/**
+ * No-op target for the `com.atakmap.app.component` discovery marker in the manifest.
+ *
+ * The filter previously pointed at `com.atakmap.app.component`, a class that does not exist in this APK, so any process
+ * that launched it — and it is exported, so any app could — crashed Meshtastic on activity instantiation. This class
+ * exists purely so the advertised component resolves to something real. It shows no UI and finishes immediately.
+ *
+ * The marker is believed to be discovery-only (enumerated via `queryIntentActivities`, never started), which is why
+ * renaming the target should be safe. **That has not been verified against a real ATAK install**, and nothing in this
+ * repository documents ATAK's matching behaviour — see the manifest comment. Note also that no `plugin-api` meta-data
+ * is declared anywhere here, so this app is not an ATAK plugin and the marker may simply be vestigial; TAK interop
+ * actually runs over the local CoT server and `AtakFileWriter`.
+ */
+class AtakPluginDiscoveryActivity : Activity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ finish()
+ }
+}
diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
index 93b34fc92d..edacbd8848 100644
--- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
+++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/AndroidBluetoothRepository.kt
@@ -36,6 +36,7 @@ import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.util.anonymize
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@@ -138,10 +139,10 @@ class AndroidBluetoothRepository(
// removeBond() is a public-but-hidden BluetoothDevice API (no SDK stub); reflection is the standard access
// path used across the Android BLE/DFU ecosystem (incl. Nordic's DFU library).
val removed = remoteDevice.javaClass.getMethod("removeBond").invoke(remoteDevice) as? Boolean ?: false
- Logger.i { "removeBond($address) -> $removed" }
+ Logger.i { "removeBond(${address.anonymize()}) -> $removed" }
removed
} catch (e: Exception) {
- Logger.w(e) { "removeBond($address) reflection failed" }
+ Logger.w(e) { "removeBond(${address.anonymize()}) reflection failed" }
false
} finally {
updateBluetoothState()
diff --git a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
index a81a5a9b1f..01c4df93c0 100644
--- a/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
+++ b/core/ble/src/androidMain/kotlin/org/meshtastic/core/ble/KablePlatformSetup.kt
@@ -22,6 +22,7 @@ import com.juul.kable.Peripheral
import com.juul.kable.PeripheralBuilder
import com.juul.kable.PooledThreadingStrategy
import com.juul.kable.toIdentifier
+import org.meshtastic.core.model.util.anonymize
/**
* Shared thread pool for Kable BLE connections.
@@ -55,9 +56,9 @@ internal actual fun PeripheralBuilder.platformConfig(device: BleDevice, autoConn
// Requesting the max MTU is critical for preventing dropped packets and stalls.
@Suppress("MagicNumber")
val negotiatedMtu = requestMtu(512)
- Logger.i { "[${device.address}] Negotiated MTU: $negotiatedMtu" }
+ Logger.i { "[${device.address.anonymize()}] Negotiated MTU: $negotiatedMtu" }
} catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- Logger.w(e) { "[${device.address}] Failed to request MTU" }
+ Logger.w(e) { "[${device.address.anonymize()}] Failed to request MTU" }
}
}
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt
index 3b12390a97..6ea51a2283 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleConnection.kt
@@ -43,6 +43,7 @@ import kotlinx.coroutines.job
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.meshtastic.core.common.util.ioDispatcher
+import org.meshtastic.core.model.util.anonymize
import kotlin.concurrent.Volatile
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -131,9 +132,9 @@ class KableBleConnection(private val scope: CoroutineScope, private val loggingC
/** Applies logging, observation exception handling, and platform config shared by both peripheral types. */
fun PeripheralBuilder.commonConfig() {
- logging { applyConfig(loggingConfig, identifier = device.address) }
+ logging { applyConfig(loggingConfig, identifier = device.address.anonymize()) }
observationExceptionHandler { cause ->
- Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
+ Logger.w(cause) { "[${device.address.anonymize()}] Observation failure suppressed" }
}
platformConfig(device) { autoConnect }
}
@@ -180,7 +181,9 @@ class KableBleConnection(private val scope: CoroutineScope, private val loggingC
autoConnect =
try {
connectionScope?.let { oldScope ->
- Logger.d { "[${device.address}] Cancelling previous connectionScope before reconnect" }
+ Logger.d {
+ "[${device.address.anonymize()}] Cancelling previous connectionScope before reconnect"
+ }
oldScope.coroutineContext.job.cancel()
}
connectionScope = p.connect()
@@ -192,12 +195,12 @@ class KableBleConnection(private val scope: CoroutineScope, private val loggingC
// Already on the autoConnect path and still failing: surface a clear Disconnected
// and let the outer reconnect loop (BleRadioTransport) own the macro retry budget.
Logger.w {
- "[${device.address}] autoConnect attempt also failed; deferring to outer reconnect loop"
+ "[${device.address.anonymize()}] autoConnect also failed; deferring to outer reconnect loop"
}
_connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed))
throw e
}
- Logger.d { "[${device.address}] Direct connect failed, falling back to autoConnect" }
+ Logger.d { "[${device.address.anonymize()}] Direct connect failed, falling back to autoConnect" }
delay(AUTOCONNECT_FALLBACK_DELAY)
true
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index fa19243fac..20f83a5c90 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -23,6 +23,7 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import okio.ByteString
+import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
@@ -42,6 +43,7 @@ import org.meshtastic.core.model.source
import org.meshtastic.core.model.textMentionsNode
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.decodeOrNull
+import org.meshtastic.core.model.util.isValidCodePoint
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.DataPair
@@ -293,6 +295,13 @@ class MeshDataHandlerImpl(
val u = Waypoint.ADAPTER.decode(payload)
// A locked waypoint may only be created/updated by its owner; drop it if the sender isn't allowed to modify it.
if (!u.isModifiableBy(packet.from)) return
+ // icon is an arbitrary int on the wire and a waypoint with expire == 0 is retained indefinitely, so
+ // normalise an unrenderable code point here at the trust boundary rather than relying on every consumer to
+ // guard (0 means "use the default pushpin").
+ if (!u.icon.isValidCodePoint()) {
+ Logger.w { "Clearing an out-of-range waypoint icon code point (${u.icon})" }
+ dataPacket.bytes = Waypoint.ADAPTER.encode(u.copy(icon = 0)).toByteString()
+ }
val updateNotification = u.expire > nowSeconds.toInt()
radioInterfaceService.launchSessionWork(scope, session) {
// Persisted-owner enforcement: a stored, locked waypoint may only be modified by the node it is locked to.
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
index 12750bca28..3eac0e8570 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
@@ -147,6 +147,38 @@ class NodeManagerImpl(
return NodeIndex(nextByNum, nextById, nextCandidatesById, nextCandidatesByPublicKey)
}
+ /**
+ * Shrinks the index to at most [maxNodes] entries, never removing anything in [keep].
+ *
+ * `packet.from` is untrusted and unthrottled, so without this every novel value allocates a permanent [Node]
+ * and the index grows until the process dies. Only `core:data` reads this index — for correlation,
+ * notifications, channel lookup and staging DB writes — and every one of those readers already tolerates a
+ * miss, so dropping a cold entry degrades correlation rather than losing user-visible data. The node list and
+ * map read Room, not this index, so eviction is not visible in the UI.
+ *
+ * Eviction order is least-valuable-first: bare-packet placeholders before nodes that have sent a real NodeInfo,
+ * and within each group the least recently heard. Nodes the user has marked (favourite, ignored) are never
+ * evicted, since that is user data rather than observed mesh state.
+ *
+ * The cap is therefore best-effort rather than absolute: if protected entries alone exceed [maxNodes] the index
+ * stays above it. That is deliberate — favourite and ignored are set only by the local user, so no remote party
+ * can inflate them, and silently discarding user data to satisfy a memory bound would be the worse trade.
+ */
+ fun evictedToFit(maxNodes: Int, keep: Set<Int>): NodeIndex {
+ if (byNum.size <= maxNodes) return this
+ val evictable =
+ byNum.values
+ .filterNot { it.num in keep || it.isFavorite || it.isIgnored }
+ // Placeholders first, then oldest-heard, then node num so the outcome is deterministic.
+ .sortedWith(
+ compareByDescending<Node> { isDefaultIdentityPlaceholder(it) }
+ .thenBy { it.lastHeard }
+ .thenBy { it.num },
+ )
+ val toDrop = evictable.take(byNum.size - maxNodes)
+ return toDrop.fold(this) { index, node -> index.remove(node.num) }
+ }
+
/**
* Removes [num] from both indices. When the removed node's user ID was the [byId] representative and another
* surviving node shares that ID, [preferredNum] wins when present; otherwise deterministic ordering selects the
@@ -362,6 +394,14 @@ class NodeManagerImpl(
coarsenCoordinate(storedLatI, incomingBits) == (incoming.latitude_i ?: 0) &&
coarsenCoordinate(storedLonI, incomingBits) == (incoming.longitude_i ?: 0)
}
+
+ /**
+ * Ceiling on entries in the in-memory node index. See [NodeIndex.evictedToFit].
+ *
+ * An order of magnitude above the largest NodeDB firmware exposes (`MAX_NUM_NODES` is 100–250 by target), so a
+ * legitimately busy mesh never reaches it and only sustained novel-`from` traffic does.
+ */
+ const val MAX_IN_MEMORY_NODES = 2_000
}
override fun loadCachedNodeDB() {
@@ -395,10 +435,12 @@ class NodeManagerImpl(
// No live mutation since capture: install the filtered snapshot directly. Preserve any
// local-node number learned during the session; fall back to the persisted value only when
// we have none yet.
- state.copy(
- index = NodeIndex.fromByNum(filteredSnapshot),
- localNodeNum = state.localNodeNum ?: persistedLocalNum,
- )
+ state
+ .copy(
+ index = NodeIndex.fromByNum(filteredSnapshot),
+ localNodeNum = state.localNodeNum ?: persistedLocalNum,
+ )
+ .withBoundedIndex()
} else {
// Live state changed during the load window. Merge: current nodes (with their fresher
// fields) win over snapshot rows at the same num; snapshot rows only fill slots that the
@@ -407,10 +449,12 @@ class NodeManagerImpl(
val liveByNum = state.index.byNum
val merged = filteredSnapshot.toMutableMap()
liveByNum.forEach { (num, node) -> merged[num] = node }
- state.copy(
- index = NodeIndex.fromByNum(merged),
- localNodeNum = state.localNodeNum ?: persistedLocalNum,
- )
+ state
+ .copy(
+ index = NodeIndex.fromByNum(merged),
+ localNodeNum = state.localNodeNum ?: persistedLocalNum,
+ )
+ .withBoundedIndex()
}
}
}
@@ -507,6 +551,20 @@ class NodeManagerImpl(
internal fun getOrCreateNode(n: Int, channel: Int = 0): Node =
nodeState.value.index.byNum[n] ?: createDefaultNode(n, channel)
+ /**
+ * Applies [MAX_IN_MEMORY_NODES] to this state's index, protecting the local node and [alsoKeep].
+ *
+ * EVERY path that installs an index must go through this. There are three — the ordinary [updateNodeState] reducer,
+ * the [handleReceivedUser] commit, and the [loadCachedNodeDB] snapshot install — and bounding only the first left
+ * the NodeInfo path, which is the one an unauthenticated peer drives most directly, completely unbounded.
+ *
+ * Returns the receiver unchanged when nothing needs evicting, so a CAS on the result stays cheap.
+ */
+ private fun NodeState.withBoundedIndex(alsoKeep: Int? = null): NodeState {
+ val bounded = index.evictedToFit(MAX_IN_MEMORY_NODES, keep = setOfNotNull(alsoKeep, localNodeNum))
+ return if (bounded === index) this else copy(index = bounded)
+ }
+
private data class NodeStateChange(val previous: Node, val next: Node)
private fun updateNodeState(nodeNum: Int, channel: Int, transform: (Node) -> Node): NodeStateChange? {
@@ -517,7 +575,9 @@ class NodeManagerImpl(
val current = state.index.byNum[nodeNum] ?: createDefaultNode(nodeNum, channel)
val next = transform(current)
change = NodeStateChange(previous = current, next = next)
- state.copy(index = state.index.put(nodeNum, next), revision = state.revision + 1)
+ state
+ .copy(index = state.index.put(nodeNum, next), revision = state.revision + 1)
+ .withBoundedIndex(alsoKeep = nodeNum)
}
return change
}
@@ -582,16 +642,18 @@ class NodeManagerImpl(
)
receivedUserReductionHook?.invoke()
val after =
- before.copy(
- index = transition.after,
- retiredNodeNums =
- transition.unretireNodeNum?.let { before.retiredNodeNums.removing(it) }
- ?: before.retiredNodeNums,
- retiredKeyHints =
- transition.unretireNodeNum?.let { before.retiredKeyHints.removing(it) }
- ?: before.retiredKeyHints,
- revision = before.revision + 1,
- )
+ before
+ .copy(
+ index = transition.after,
+ retiredNodeNums =
+ transition.unretireNodeNum?.let { before.retiredNodeNums.removing(it) }
+ ?: before.retiredNodeNums,
+ retiredKeyHints =
+ transition.unretireNodeNum?.let { before.retiredKeyHints.removing(it) }
+ ?: before.retiredKeyHints,
+ revision = before.revision + 1,
+ )
+ .withBoundedIndex(alsoKeep = fromNum)
if (nodeState.compareAndSet(before, after)) {
Logger.d {
val keyStr = resolveValidatedPublicKeyHint(p.public_key)?.let(::publicKeyLogFingerprint) ?: "none"
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
index f7d3f41adc..060474c793 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
@@ -83,6 +83,104 @@ class NodeManagerImplTest {
nodeManager.notificationTitleFormatter = { shortName -> "New node seen: $shortName" }
}
+ // ---------- In-memory index bounding ----------
+
+ /** Drives [NodeManagerImpl.updateNode], the growth path for the in-memory index, with distinct node numbers. */
+ private fun floodDistinctNodes(count: Int, startAt: Int = 100_000) {
+ repeat(count) { i -> nodeManager.updateNode(startAt + i) { node -> node.copy(lastHeard = i) } }
+ }
+
+ /**
+ * Floods past the cap and asserts eviction actually ran.
+ *
+ * The protection tests below must assert this too: without it they pass trivially when eviction is disabled, since
+ * nothing being evicted also means the protected node survives.
+ */
+ private fun floodPastCapAndAssertEvicted() {
+ floodDistinctNodes(NodeManagerImpl.MAX_IN_MEMORY_NODES + 500)
+ assertTrue(
+ nodeManager.nodeDBbyNodeNum.size <= NodeManagerImpl.MAX_IN_MEMORY_NODES,
+ "eviction did not run: index is ${nodeManager.nodeDBbyNodeNum.size}",
+ )
+ }
+
+ @Test
+ fun `a flood of novel node numbers cannot grow the in-memory index without bound`() {
+ // packet.from is unauthenticated, so every novel value would otherwise allocate a permanent Node. The index is
+ // read only inside core:data for correlation, and every consumer tolerates a miss, so eviction is safe here.
+ floodDistinctNodes(NodeManagerImpl.MAX_IN_MEMORY_NODES + 500)
+
+ assertTrue(
+ nodeManager.nodeDBbyNodeNum.size <= NodeManagerImpl.MAX_IN_MEMORY_NODES,
+ "index grew to ${nodeManager.nodeDBbyNodeNum.size}, over the ${NodeManagerImpl.MAX_IN_MEMORY_NODES} cap",
+ )
+ }
+
+ @Test
+ fun `a flood of novel NodeInfo packets cannot grow the in-memory index without bound`() {
+ // handleReceivedUser commits its own index rather than going through updateNodeState, so bounding only the
+ // latter left this path — the one an unauthenticated peer drives most directly — completely unbounded.
+ repeat(NodeManagerImpl.MAX_IN_MEMORY_NODES + 500) { i ->
+ val num = 200_000 + i
+ nodeManager.handleReceivedUser(
+ num,
+ User(
+ id = NodeAddress.numToDefaultId(num),
+ long_name = "Flood $i",
+ short_name = "F$i",
+ hw_model = HardwareModel.TLORA_V2,
+ ),
+ )
+ }
+
+ assertTrue(
+ nodeManager.nodeDBbyNodeNum.size <= NodeManagerImpl.MAX_IN_MEMORY_NODES,
+ "index grew to ${nodeManager.nodeDBbyNodeNum.size} via the NodeInfo path",
+ )
+ }
+
+ @Test
+ fun `eviction never drops the local node`() {
+ // Deliberately left as a bare placeholder with the oldest lastHeard, i.e. the FIRST node eviction would
+ // otherwise pick. A local node with real NodeInfo survives incidentally by sorting last, which would not
+ // exercise the explicit exemption at all.
+ val myNum = 4242
+ nodeManager.setMyNodeNum(myNum)
+ nodeManager.updateNode(myNum) { it.copy(lastHeard = -1) }
+
+ floodPastCapAndAssertEvicted()
+
+ assertNotNull(nodeManager.nodeDBbyNodeNum[myNum], "the local node must never be evicted")
+ }
+
+ @Test
+ fun `eviction never drops user-marked nodes`() {
+ val favourite = 5150
+ val ignored = 5151
+ nodeManager.updateNode(favourite) { it.copy(isFavorite = true) }
+ nodeManager.updateNode(ignored) { it.copy(isIgnored = true) }
+
+ floodPastCapAndAssertEvicted()
+
+ assertNotNull(nodeManager.nodeDBbyNodeNum[favourite], "a favourite must never be evicted")
+ assertNotNull(nodeManager.nodeDBbyNodeNum[ignored], "an ignored node must never be evicted")
+ }
+
+ @Test
+ fun `eviction prefers placeholders over nodes with a real identity`() {
+ val identified = 7777
+ nodeManager.updateNode(identified) {
+ it.copy(user = it.user.copy(long_name = "Real Node", hw_model = HardwareModel.TLORA_V2))
+ }
+
+ floodPastCapAndAssertEvicted()
+
+ assertNotNull(
+ nodeManager.nodeDBbyNodeNum[identified],
+ "a node with a real NodeInfo identity should outlive bare-packet placeholders",
+ )
+ }
+
@Test
fun `getOrCreateNode creates default user for unknown node`() {
val nodeNum = 1234
diff --git a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/BleAddressLoggingTest.kt b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/BleAddressLoggingTest.kt
new file mode 100644
index 0000000000..f73ea7268b
--- /dev/null
+++ b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/BleAddressLoggingTest.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.konsist
+
+import com.lemonappdev.konsist.api.Konsist
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+/**
+ * A BLE MAC address is a stable hardware identifier for the user's radio, and Kermit forwards every `Logger` call to
+ * Datadog and Crashlytics on analytics the user is opted into by default. So an address must never be interpolated into
+ * log or exception text raw — it goes through `Any?.anonymize()`, which keeps only a short suffix.
+ *
+ * This is enforced as an architecture rule rather than by review because the failure mode is missing a site: a previous
+ * attempt anonymised the hand-written log statements in `core/ble` and missed the Kable `identifier`, which stamps the
+ * address onto *every* line the BLE library emits, plus further sites in the DFU transports and WiFi provisioning.
+ *
+ * Scoped to the BLE-adjacent modules so matching on the `address` suffix stays low-noise.
+ */
+class BleAddressLoggingTest {
+
+ private val scannedPathFragments =
+ listOf("/core/ble/", "/feature/firmware/", "/feature/wifi-provision/", "/feature/connections/")
+
+ /**
+ * Files where an address is used as an identity rather than as diagnostic text — building the connection string or
+ * a device label the user themselves is looking at. Anonymising these would break functionality.
+ */
+ private val identityUseAllowlist = listOf("DeviceListEntry.kt")
+
+ /** Interpolation of anything ending in `address`, e.g. `${device.address}` or `$address`. */
+ private val interpolatedAddress = Regex("""\$\{?[A-Za-z0-9_.]*[aA]ddress}?""")
+
+ /**
+ * Files this rule covers.
+ *
+ * Extracted and asserted non-empty by [the scan actually reaches the BLE sources] because a rule whose scope
+ * silently matches nothing passes for the wrong reason — which is the whole failure mode this test exists to catch.
+ */
+ private fun scannedFiles() = Konsist.scopeFromProject()
+ .files
+ .filter { file -> scannedPathFragments.any { it in file.path } }
+ .filterNot { file -> identityUseAllowlist.any { file.path.endsWith(it) } }
+
+ @Test
+ fun `the scan actually reaches the BLE sources`() {
+ val paths = scannedFiles().map { it.path }
+
+ assertTrue(paths.isNotEmpty(), "scoped scan matched no files at all — the path filter is wrong")
+ assertTrue(
+ paths.any { it.endsWith("KableBleConnection.kt") },
+ "expected core/ble sources in scope; got ${paths.size} files, e.g. ${paths.take(3)}",
+ )
+ }
+
+ @Test
+ fun `a BLE address is never interpolated into log or exception text without anonymize`() {
+ val offenders =
+ scannedFiles().flatMap { file ->
+ file.text.lines().withIndex().mapNotNull { (index, line) ->
+ val isDiagnostic = "Logger." in line || "throw " in line || "check(" in line || "require(" in line
+ val interpolates = interpolatedAddress.containsMatchIn(line)
+ val anonymised = "anonymize" in line
+ if (isDiagnostic && interpolates && !anonymised) {
+ "${file.path.substringAfterLast("/kotlin/")}:${index + 1}: ${line.trim()}"
+ } else {
+ null
+ }
+ }
+ }
+
+ assertTrue(
+ offenders.isEmpty(),
+ "BLE addresses must be anonymised in diagnostic text. Offending lines:\n" + offenders.joinToString("\n"),
+ )
+ }
+
+ /**
+ * Kable stamps its `Logging.identifier` onto every line it emits, so passing a raw address there leaks it from
+ * library-internal logging that no per-call-site review would catch.
+ */
+ @Test
+ fun `the Kable logging identifier is never a raw address`() {
+ val offenders =
+ Konsist.scopeFromProject()
+ .files
+ .filter { "/core/ble/" in it.path }
+ .flatMap { file ->
+ file.text.lines().withIndex().mapNotNull { (index, line) ->
+ if ("identifier =" in line && "address" in line && "anonymize" !in line) {
+ "${file.path.substringAfterLast("/kotlin/")}:${index + 1}: ${line.trim()}"
+ } else {
+ null
+ }
+ }
+ }
+
+ assertTrue(
+ offenders.isEmpty(),
+ "Kable's logging identifier must be anonymised. Offending lines:\n" + offenders.joinToString("\n"),
+ )
+ }
+}
diff --git a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CommonMainFrameworkBoundaryTest.kt b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CommonMainFrameworkBoundaryTest.kt
index 346f2c7835..6564aeeeb0 100644
--- a/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CommonMainFrameworkBoundaryTest.kt
+++ b/core/konsist/src/jvmTest/kotlin/org/meshtastic/core/konsist/CommonMainFrameworkBoundaryTest.kt
@@ -16,9 +16,11 @@
*/
package org.meshtastic.core.konsist
+// kotlin.test.Test, NOT org.junit.Test: the test runner here is JUnit Jupiter, which does not discover JUnit 4
+// annotations without the vintage engine. With `org.junit.Test` these two rules silently never executed.
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.verify.assertFalse
-import org.junit.Test
+import kotlin.test.Test
/**
* Enforces the KMP framework-bleed rule from AGENTS.md: shared code in any `commonMain` source set must never depend on
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/CodePointUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/CodePointUtils.kt
new file mode 100644
index 0000000000..4432e37da6
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/CodePointUtils.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model.util
+
+/** Round Pushpin (📍) — the default waypoint icon, and the fallback for an unusable code point. */
+const val PUSHPIN_CODE_POINT = 0x1F4CD
+
+private const val MAX_CODE_POINT = 0x10FFFF
+private const val MIN_SUPPLEMENTARY_CODE_POINT = 0x10000
+private const val MIN_HIGH_SURROGATE = 0xD800
+private const val MIN_LOW_SURROGATE = 0xDC00
+private const val MAX_LOW_SURROGATE = 0xDFFF
+private const val SURROGATE_SHIFT = 10
+private const val LOW_SURROGATE_MASK = 0x3FF
+
+/**
+ * Whether this is a Unicode scalar value: within the code point range and not an unpaired surrogate.
+ *
+ * `Character.isValidCodePoint` is JVM-only and additionally accepts lone surrogates, so this does the range check
+ * explicitly for `commonMain` callers.
+ */
+fun Int.isValidCodePoint(): Boolean = this in 0..MAX_CODE_POINT && this !in MIN_HIGH_SURROGATE..MAX_LOW_SURROGATE
+
+/**
+ * The icon to render for a waypoint, where `0` on the wire means "unset" and selects [PUSHPIN_CODE_POINT].
+ *
+ * Zero is a *valid* code point, so [toCodePointString] renders it as U+0000 rather than falling back. That matters
+ * because the trust-boundary clamp in `MeshDataHandlerImpl` normalises an unrenderable icon to `0` — every display and
+ * send path therefore has to read zero as the default rather than as a control character.
+ */
+fun Int.waypointIconOrDefault(): Int = if (this == 0) PUSHPIN_CODE_POINT else this
+
+/**
+ * Renders this Unicode code point as a string, substituting [fallback] when the value is not a usable scalar value.
+ *
+ * Code points that reach the UI may come from untrusted input, so they must not be handed to `Character.toChars`, which
+ * throws `IllegalArgumentException` on anything out of range.
+ */
+fun Int.toCodePointString(fallback: Int = PUSHPIN_CODE_POINT): String {
+ val codePoint =
+ when {
+ isValidCodePoint() -> this
+ fallback.isValidCodePoint() -> fallback
+ else -> PUSHPIN_CODE_POINT
+ }
+ if (codePoint < MIN_SUPPLEMENTARY_CODE_POINT) return codePoint.toChar().toString()
+ val offset = codePoint - MIN_SUPPLEMENTARY_CODE_POINT
+ return charArrayOf(
+ (MIN_HIGH_SURROGATE + (offset ushr SURROGATE_SHIFT)).toChar(),
+ (MIN_LOW_SURROGATE + (offset and LOW_SURROGATE_MASK)).toChar(),
+ )
+ .concatToString()
+}
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CodePointUtilsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CodePointUtilsTest.kt
new file mode 100644
index 0000000000..570424d236
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CodePointUtilsTest.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class CodePointUtilsTest {
+
+ @Test
+ fun `isValidCodePoint accepts scalar values`() {
+ assertTrue('A'.code.isValidCodePoint())
+ assertTrue(PUSHPIN_CODE_POINT.isValidCodePoint())
+ assertTrue(0.isValidCodePoint())
+ assertTrue(0x10FFFF.isValidCodePoint())
+ }
+
+ @Test
+ fun `isValidCodePoint rejects out of range and surrogates`() {
+ assertFalse((-1).isValidCodePoint())
+ assertFalse(0x110000.isValidCodePoint())
+ assertFalse(Int.MAX_VALUE.isValidCodePoint())
+ assertFalse(Int.MIN_VALUE.isValidCodePoint())
+ assertFalse(0xD800.isValidCodePoint())
+ assertFalse(0xDFFF.isValidCodePoint())
+ }
+
+ @Test
+ fun `toCodePointString encodes bmp and supplementary planes`() {
+ assertEquals("A", 'A'.code.toCodePointString())
+ assertEquals("📍", PUSHPIN_CODE_POINT.toCodePointString())
+ assertEquals("", 0x10FFFF.toCodePointString())
+ }
+
+ @Test
+ fun `toCodePointString falls back instead of throwing on out-of-range input`() {
+ // Values that reach Waypoint.icon from the wire; each of these throws from Character.toChars.
+ assertEquals("📍", (-1).toCodePointString())
+ assertEquals("📍", 0x110000.toCodePointString())
+ assertEquals("📍", 0xFFFFFFFF.toInt().toCodePointString())
+ assertEquals("📍", 0xD800.toCodePointString())
+ }
+
+ @Test
+ fun `zero renders as a control character without the waypoint helper`() {
+ // The premise for waypointIconOrDefault existing: zero is a valid scalar value, so toCodePointString has no
+ // reason to substitute anything. If this ever starts returning the pushpin, the helper is redundant.
+ assertEquals("\u0000", 0.toCodePointString())
+ }
+
+ @Test
+ fun `waypointIconOrDefault maps the unset icon to the pushpin`() {
+ assertEquals(PUSHPIN_CODE_POINT, 0.waypointIconOrDefault())
+ assertEquals("📍", 0.waypointIconOrDefault().toCodePointString())
+ }
+
+ @Test
+ fun `waypointIconOrDefault leaves a chosen icon alone`() {
+ // Includes an out-of-range value: the helper resolves "unset", it is not a validity clamp — that is
+ // toCodePointString's job, and conflating the two would silently discard the distinction.
+ assertEquals('A'.code, 'A'.code.waypointIconOrDefault())
+ assertEquals(0x1F600, 0x1F600.waypointIconOrDefault())
+ assertEquals(-1, (-1).waypointIconOrDefault())
+ }
+
+ @Test
+ fun `toCodePointString honours an explicit fallback`() {
+ assertEquals("?", (-1).toCodePointString(fallback = '?'.code))
+ // A bogus fallback must not propagate the throw either.
+ assertEquals("📍", (-1).toCodePointString(fallback = -2))
+ }
+}
diff --git a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
index d17abd4a30..b0a63ee5a8 100644
--- a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
+++ b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
@@ -19,4 +19,6 @@ package org.meshtastic.core.model.util
/** No-op stubs for core:model on iOS. */
actual fun getShortDateTime(time: Long): String = ""
-actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size)
+// Deliberately not a no-op: this backs channel PSK and private-key generation, so an all-zeros stub must not be
+// allowed to ship silently. Fail loudly until it is wired to SecRandomCopyBytes.
+actual fun platformRandomBytes(size: Int): ByteArray = error("platformRandomBytes is not implemented on iOS")
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt
index 43a6810f9f..85194a15e4 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/transport/StreamFrameCodec.kt
@@ -41,6 +41,9 @@ class StreamFrameCodec(
const val MAX_TO_FROM_RADIO_SIZE = 512
const val HEADER_SIZE = 4
+ /** Cap on the device-log line buffer. Bounds [debugOut] for a stream that never yields a newline. */
+ const val MAX_DEBUG_LINE_LENGTH = 512
+
/** Default Meshtastic TCP service port. */
const val DEFAULT_TCP_PORT = 4403
@@ -141,7 +144,13 @@ class StreamFrameCodec(
debugLineBuf.clear()
}
- /** Print device serial debug output to the logger. */
+ /**
+ * Print device serial debug output to the logger.
+ *
+ * Any byte stream that never yields a newline accumulates here, so the buffer is capped: a stream of non-`START1`
+ * bytes with no `\n` would otherwise grow the heap without limit. At the cap the line is flushed as-is and the
+ * buffer reset, which keeps the output readable rather than silently dropping it.
+ */
private fun debugOut(b: Byte) {
when (val c = b.toInt().toChar()) {
'\r' -> {}
@@ -151,7 +160,13 @@ class StreamFrameCodec(
debugLineBuf.clear()
}
- else -> debugLineBuf.append(c)
+ else -> {
+ debugLineBuf.append(c)
+ if (debugLineBuf.length >= MAX_DEBUG_LINE_LENGTH) {
+ Logger.d { "$logTag DeviceLog: $debugLineBuf" }
+ debugLineBuf.clear()
+ }
+ }
}
}
}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 6ad4ffe54b..2386ffb707 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -337,6 +337,7 @@
<string name="debug_logcat_empty">No app logs to show</string>
<string name="debug_logcat_refresh">Refresh</string>
<string name="debug_logs_export">Export Logs</string>
+ <string name="debug_logs_export_warning">This file can contain your message text, precise locations, and node details. Review it before sharing it publicly.</string>
<string name="debug_logs_exported">Logs exported</string>
<string name="debug_panel">Debug Panel</string>
<string name="debug_search_clear">Clear search</string>
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
index e2325334f0..4b6f362a56 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/SharedRadioInterfaceService.kt
@@ -310,14 +310,21 @@ class SharedRadioInterfaceService(
}
}
- // Unbounded Channel preserves strict FIFO delivery of incoming radio bytes, which the
- // firmware handshake depends on (initial config packet ordering). A SharedFlow with
- // `launch { emit() }` per packet reorders under concurrent dispatch and breaks config load.
- // trySend on an UNLIMITED channel never suspends and never drops, so handleFromRadio can
- // remain a non-suspend synchronous callback.
- private val _receivedData = Channel<ReceivedRadioFrame>(Channel.UNLIMITED)
+ // A Channel preserves strict FIFO delivery of incoming radio bytes, which the firmware
+ // handshake depends on (initial config packet ordering). A SharedFlow with `launch { emit() }`
+ // per packet reorders under concurrent dispatch and breaks config load. trySend never
+ // suspends, so handleFromRadio can remain a non-suspend synchronous callback.
+ //
+ // Bounded rather than UNLIMITED: inbound frames arrive faster than they can be processed under
+ // sustained traffic, and an unbounded queue grows with no drop policy at all. At the cap
+ // trySend fails and the newest frame is dropped, which keeps the already-queued (earlier)
+ // frames and so preserves the ordering the handshake relies on.
+ private val _receivedData = Channel<ReceivedRadioFrame>(RECEIVE_QUEUE_CAPACITY)
override val receivedData: Flow<ReceivedRadioFrame> = _receivedData.receiveAsFlow()
+ /** Running count of frames dropped because the queue was full. Diagnostic only; see [enqueueReceivedData]. */
+ private var droppedFrameCount = 0L
+
private val _meshActivity =
MutableSharedFlow<MeshActivity>(extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val meshActivity: Flow<MeshActivity> = _meshActivity.asFlow()
@@ -376,6 +383,40 @@ class SharedRadioInterfaceService(
private fun now(): Long = clockMillis()
companion object {
+ /**
+ * Capacity of the inbound frame queue.
+ *
+ * Sized against the burst a connect produces, which is roughly `35 + 5N` frames for a NodeDB of N nodes:
+ * - ~35 fixed: `my_info`, metadata, ~10 config, ~14 moduleConfig, 8 channels, deviceui, `config_complete`.
+ * - N thin `NodeInfo` frames — one per *hot-store* node. Warm-tier entries (firmware `WARM_NODE_COUNT`, up to
+ * 2000 on a native host) are identity-only records for evicted nodes and are NOT streamed to the phone, so
+ * they do not contribute here.
+ * - Up to 4N more from the post-`config_complete` replay drain, which re-sends stored position, telemetry,
+ * environment and status as ordinary mesh packets — one of each per node.
+ *
+ * N is firmware `MAX_NUM_NODES`: 250 on portduino/native-host and top-tier ESP32-S3, 120 on nRF52840 and
+ * generic ESP32, 10 on STM32WL. So the realistic worst case is a Linux/Pi node at N=250 → ~1285 frames, and
+ * those arrive over TCP, the transport most able to outrun the consumer. 8192 keeps roughly 6x headroom over
+ * that; a custom build raising `MAX_NUM_NODES` past ~1630 would need this raised too.
+ *
+ * Memory stays bounded at capacity x frame size. On the stream transports a frame cannot exceed
+ * `StreamFrameCodec.MAX_TO_FROM_RADIO_SIZE` (512 B); the BLE path passes through whatever the GATT read
+ * returned, which ATT caps at 512 B in practice rather than by anything enforced here. A thin `NodeInfo` is
+ * closer to 100 B, so the realistic ceiling is well under the ~4 MB absolute worst case.
+ */
+ const val RECEIVE_QUEUE_CAPACITY = 8192
+
+ /** Log one dropped-frame warning per this many drops. See [enqueueReceivedData]. */
+ private const val DROP_LOG_INTERVAL = 512L
+
+ /**
+ * Per-frame ceiling, matching `StreamFrameCodec.MAX_TO_FROM_RADIO_SIZE`.
+ *
+ * Duplicated rather than imported because `core:service` does not depend on `core:network`; the stream codec
+ * enforces the same number on its own path, and ATT caps BLE at the same value in practice.
+ */
+ const val MAX_FRAME_BYTES = 512
+
private const val HEARTBEAT_INTERVAL_MILLIS = 30 * 1000L
// If we haven't received any data from the radio within this window after sending a
@@ -610,6 +651,12 @@ class SharedRadioInterfaceService(
Logger.d { "restartTransport: aborted, disconnect requested during stop" }
return@withLock
}
+ // Drop whatever the dead session left queued before admitting the replacement. The consumer discards
+ // stale-generation frames on dequeue, but they still occupy slots until then — and now that the queue
+ // is bounded, a backlog carried across the cycle can make the fresh session's handshake frames fail
+ // trySend. `MeshServiceOrchestrator.start()` drains for its own stop/start path, but a transport-level
+ // restart does not go through it, so the drain has to happen here too.
+ resetReceivedBuffer()
// startTransportLocked() re-validates the selected address (no-op if null) and emits
// Connected through the transport callbacks (via the new transport's onConnect) once
// the fresh transport comes up — there is no Connecting emission at the transport
@@ -932,14 +979,31 @@ class SharedRadioInterfaceService(
private fun enqueueReceivedData(bytes: ByteArray, session: RadioTransportSession) {
try {
lastDataReceivedMillis = now()
- // trySend synchronously onto the unbounded Channel so packet order matches arrival
- // order. The previous `launch { emit() }` pattern dispatched each packet onto a
- // fresh coroutine, letting the scheduler reorder them — which broke the firmware
- // config handshake (see PhoneAPI.cpp initial-handshake sequence).
+ // trySend synchronously onto the Channel so packet order matches arrival order. The
+ // previous `launch { emit() }` pattern dispatched each packet onto a fresh coroutine,
+ // letting the scheduler reorder them — which broke the firmware config handshake
+ // (see PhoneAPI.cpp initial-handshake sequence).
+ // Reject before the copy: the channel bounds the frame COUNT, so without a per-frame ceiling a transport
+ // handing over an oversized buffer defeats the memory bound the capacity is supposed to give. The stream
+ // codec already enforces this on its own path; BLE passes through whatever the GATT read returned.
+ if (bytes.size > MAX_FRAME_BYTES) {
+ Logger.w { "Discarding oversized ${bytes.size}-byte frame (max $MAX_FRAME_BYTES)" }
+ return
+ }
val frame = ReceivedRadioFrame(payload = bytes.toByteString(), session = session.context)
val result = _receivedData.trySend(frame)
if (result.isFailure) {
- Logger.e(result.exceptionOrNull()) { "Failed to enqueue ${bytes.size} received bytes; dropping packet" }
+ // Rate-limited on purpose: drops only happen under sustained inbound traffic, and Kermit forwards to
+ // Datadog/Crashlytics, so logging every drop would turn a bounded memory problem into unbounded
+ // network and battery use. The counter is deliberately unsynchronised — a racy count only skews a
+ // diagnostic line. A full queue reports failure with no exception; a closed channel carries one.
+ val drops = ++droppedFrameCount
+ if (drops == 1L || drops % DROP_LOG_INTERVAL == 0L) {
+ Logger.w(result.exceptionOrNull()) {
+ "Dropped ${bytes.size} received bytes ($drops total); receive queue at capacity " +
+ "$RECEIVE_QUEUE_CAPACITY or closed"
+ }
+ }
}
_meshActivity.tryEmit(MeshActivity.Receive)
} catch (t: Throwable) {
diff --git a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt
index 70ef78959a..3a3943849e 100644
--- a/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt
+++ b/core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt
@@ -17,6 +17,16 @@
package org.meshtastic.core.ui.util
import android.content.ClipData
+import android.content.ClipDescription
+import android.os.Build
+import android.os.PersistableBundle
import androidx.compose.ui.platform.ClipEntry
-actual fun createClipEntry(text: String, label: String): ClipEntry = ClipEntry(ClipData.newPlainText(label, text))
+actual fun createClipEntry(text: String, label: String, sensitive: Boolean): ClipEntry {
+ val clip = ClipData.newPlainText(label, text)
+ // EXTRA_IS_SENSITIVE is API 33+; there is no equivalent below that, so the clip is created unmarked there.
+ if (sensitive && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ clip.description.extras = PersistableBundle().apply { putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) }
+ }
+ return ClipEntry(clip)
+}
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/CopyIconButton.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/CopyIconButton.kt
index 2648bcf7b1..679a43ba38 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/CopyIconButton.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/CopyIconButton.kt
@@ -30,11 +30,18 @@ import org.meshtastic.core.ui.icon.Copy
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.createClipEntry
+/**
+ * Copy-to-clipboard icon button.
+ *
+ * Set [sensitive] when [valueToCopy] is key material, so the clip is marked and the OS does not surface the value in
+ * its paste preview. See [createClipEntry].
+ */
@Composable
fun CopyIconButton(
valueToCopy: String,
modifier: Modifier = Modifier,
label: String = stringResource(Res.string.copy),
+ sensitive: Boolean = false,
) {
val clipboardManager = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
@@ -42,7 +49,7 @@ fun CopyIconButton(
modifier = modifier,
onClick = {
coroutineScope.launch {
- val clipEntry = createClipEntry(valueToCopy)
+ val clipEntry = createClipEntry(valueToCopy, sensitive = sensitive)
clipboardManager.setClipEntry(clipEntry)
}
},
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt
index 106050d1e9..dfcc23c033 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/QrDialog.kt
@@ -149,7 +149,10 @@ fun QrDialog(title: String, uriString: String, onDismiss: () -> Unit) {
}
IconButton(
onClick = {
- coroutineScope.launch { clipboardManager.setClipEntry(createClipEntry(uriString)) }
+ // The channel URL embeds channel PSKs, so mark the clip as sensitive.
+ coroutineScope.launch {
+ clipboardManager.setClipEntry(createClipEntry(uriString, sensitive = true))
+ }
},
) {
Icon(imageVector = MeshtasticIcons.Copy, contentDescription = stringResource(Res.string.copy))
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt
index 53bfa28fef..5844691611 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt
@@ -18,5 +18,11 @@ package org.meshtastic.core.ui.util
import androidx.compose.ui.platform.ClipEntry
-/** Creates a platform-appropriate [ClipEntry] for the given text. */
-expect fun createClipEntry(text: String, label: String = ""): ClipEntry
+/**
+ * Creates a platform-appropriate [ClipEntry] for the given text.
+ *
+ * Set [sensitive] for key material or anything else that should not be surfaced by the OS: on Android it marks the clip
+ * so the system does not show the copied content in the paste preview toast, and clipboard-reading tools are asked to
+ * treat it as secret. Pass it for private keys and for channel URLs, which carry channel PSKs.
+ */
+expect fun createClipEntry(text: String, label: String = "", sensitive: Boolean = false): ClipEntry
diff --git a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
index f2203eb3d1..2f3fe81e84 100644
--- a/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
+++ b/core/ui/src/iosMain/kotlin/org/meshtastic/core/ui/util/NoopStubs.kt
@@ -23,7 +23,7 @@ import androidx.compose.ui.text.TextLinkStyles
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.CommonUri
-actual fun createClipEntry(text: String, label: String): ClipEntry =
+actual fun createClipEntry(text: String, label: String, sensitive: Boolean): ClipEntry =
throw UnsupportedOperationException("ClipEntry instantiation not supported on iOS stub")
actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): AnnotatedString = AnnotatedString(html)
diff --git a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt
index 89d278914f..5ca23d236d 100644
--- a/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt
+++ b/core/ui/src/jvmMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt
@@ -19,5 +19,7 @@ package org.meshtastic.core.ui.util
import androidx.compose.ui.platform.ClipEntry
import java.awt.datatransfer.StringSelection
+// `sensitive` has no AWT equivalent — the desktop clipboard carries no such flag — so it is accepted and ignored.
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
-actual fun createClipEntry(text: String, label: String): ClipEntry = ClipEntry(StringSelection(text))
+actual fun createClipEntry(text: String, label: String, sensitive: Boolean): ClipEntry =
+ ClipEntry(StringSelection(text))
diff --git a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
index 058f33499c..944d2e5772 100644
--- a/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
+++ b/feature/connections/src/androidMain/kotlin/org/meshtastic/feature/connections/AndroidScannerViewModel.kt
@@ -127,7 +127,7 @@ class AndroidScannerViewModel(
Logger.i { "User approved USB access" }
changeDeviceAddress(entry.fullAddress)
} else {
- Logger.e { "USB permission denied for device ${entry.address}" }
+ Logger.e { "USB permission denied for device ${entry.address.anonymize()}" }
serviceRepository.setErrorMessage(
text = getString(Res.string.usb_permission_denied),
severity = Severity.Warn,
diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts
index 118fe94c07..fab7287d0b 100644
--- a/feature/firmware/build.gradle.kts
+++ b/feature/firmware/build.gradle.kts
@@ -17,6 +17,9 @@
plugins {
alias(libs.plugins.meshtastic.kmp.feature)
+ // Shares the bounded zip extraction (ZipExtraction.kt) between the Android and desktop JVM file handlers, which
+ // previously carried two independent copies of the same logic.
+ alias(libs.plugins.meshtastic.kmp.jvm.android)
alias(libs.plugins.meshtastic.kotlinx.serialization)
}
diff --git a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt
index 186bb532f9..6ec2bb4649 100644
--- a/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt
+++ b/feature/firmware/src/androidMain/kotlin/org/meshtastic/feature/firmware/AndroidFirmwareFileHandler.kt
@@ -38,6 +38,7 @@ import org.meshtastic.core.model.DeviceHardware
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
+import java.io.InputStream
import java.net.URI
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
@@ -289,23 +290,31 @@ class AndroidFirmwareFileHandler(private val context: Context, private val clien
if (platformUri.scheme == "file") uri.pathSegments.lastOrNull()?.takeIf { it.isNotBlank() } else null
}
+ /**
+ * Fully expands [artifact] into memory, keyed by entry name.
+ *
+ * Streams from the artifact rather than buffering it whole, and delegates the bounds to [extractZipEntriesBounded]
+ * so this and the desktop handler cannot drift apart. The [getFileSize] check is only a cheap early rejection — it
+ * returns 0 for a provider that declines to report a length, so the inflation bound inside the extractor is what
+ * actually protects the heap.
+ */
override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map<String, ByteArray> =
withContext(ioDispatcher) {
- val entries = mutableMapOf<String, ByteArray>()
- val bytes = readBytes(artifact)
- ZipInputStream(bytes.inputStream()).use { zip ->
- var entry = zip.nextEntry
- while (entry != null) {
- if (!entry.isDirectory) {
- entries[entry.name] = zip.readBytes()
- }
- zip.closeEntry()
- entry = zip.nextEntry
- }
+ val declaredSize = getFileSize(artifact)
+ require(declaredSize <= MAX_FIRMWARE_ZIP_BYTES) {
+ "Firmware archive is $declaredSize bytes, over the $MAX_FIRMWARE_ZIP_BYTES limit"
}
- entries
+ openArtifactStream(artifact).use { extractZipEntriesBounded(it) }
}
+ /** Opens [artifact] for streaming, preferring a local file and falling back to the content resolver. */
+ private fun openArtifactStream(artifact: FirmwareArtifact): InputStream {
+ val localFile = artifact.toLocalFileOrNull()
+ if (localFile != null && localFile.exists()) return localFile.inputStream()
+ return context.contentResolver.openInputStream(artifact.uri.toAndroidUri())
+ ?: throw IOException("Cannot open artifact: ${artifact.uri}")
+ }
+
private fun isValidFirmwareFile(filename: String, target: String, fileExtension: String): Boolean =
org.meshtastic.feature.firmware.isValidFirmwareFile(filename, target, fileExtension)
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index bde1d4dcf0..79d99dd204 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -844,7 +844,7 @@ class FirmwareUpdateViewModel(
}
if (result == null) {
- Logger.w { "Post-update verification timed out for $address" }
+ Logger.w { "Post-update verification timed out for ${address.anonymize()}" }
_state.value = FirmwareUpdateState.VerificationFailed
} else {
// Device is back and healthy — retire any recovery record (covers both normal and recovery updates).
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt
index ba5240f222..73261f41b0 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/WifiOtaTransport.kt
@@ -34,6 +34,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.safeCatching
+import org.meshtastic.core.model.util.anonymize
/**
* WiFi/TCP transport implementation for ESP32 Unified OTA protocol.
@@ -57,7 +58,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In
/** Connect to the device via TCP using Ktor raw sockets. */
override suspend fun connect(): Result<Unit> = withContext(ioDispatcher) {
safeCatching {
- Logger.i { "WiFi OTA: Connecting to $deviceIpAddress:$port" }
+ Logger.i { "WiFi OTA: Connecting to ${deviceIpAddress.anonymize()}:$port" }
val selector = SelectorManager(ioDispatcher)
selectorManager = selector
@@ -69,7 +70,7 @@ class WifiOtaTransport(private val deviceIpAddress: String, private val port: In
}
} catch (e: TimeoutCancellationException) {
throw OtaProtocolException.ConnectionFailed(
- "TCP connect to $deviceIpAddress:$port timed out after ${CONNECTION_TIMEOUT_MS}ms",
+ "TCP connect to ${deviceIpAddress.anonymize()}:$port timed out after $CONNECTION_TIMEOUT_MS ms",
e,
)
}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransport.kt
index 03c4ed2287..765de1b025 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransport.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/LegacyDfuTransport.kt
@@ -48,6 +48,7 @@ import org.meshtastic.core.ble.BleDevice
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.common.util.safeCatching
+import org.meshtastic.core.model.util.anonymize
import org.meshtastic.feature.firmware.ota.calculateMacPlusOne
import org.meshtastic.feature.firmware.ota.scanForBleDevice
import org.meshtastic.feature.firmware.ota.withDisconnectTripwire
@@ -119,13 +120,15 @@ internal constructor(
override suspend fun connectToDfuMode(): Result<Unit> = safeCatching {
val dfuAddress = calculateMacPlusOne(address)
val targetAddresses = setOf(address, dfuAddress)
- Logger.i { "Legacy DFU: Scanning for DFU mode device at $targetAddresses..." }
+ Logger.i { "Legacy DFU: Scanning for DFU mode device at ${targetAddresses.map { it.anonymize() }}..." }
val device =
scanForDevice { d -> d.address in targetAddresses }
- ?: throw DfuException.ConnectionFailed("DFU mode device not found. Tried: $targetAddresses")
+ ?: throw DfuException.ConnectionFailed(
+ "DFU mode device not found. Tried: ${targetAddresses.map { it.anonymize() }}",
+ )
- Logger.i { "Legacy DFU: Found DFU mode device at ${device.address} (name=${device.name}), connecting..." }
+ Logger.i { "Legacy DFU: Found DFU mode device at ${device.address.anonymize()}, connecting..." }
dfuAdvertisedName = device.name
bleConnection.connectionState
@@ -134,7 +137,7 @@ internal constructor(
val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT)
if (connected is BleConnectionState.Disconnected) {
- throw DfuException.ConnectionFailed("Failed to connect to DFU device ${device.address}")
+ throw DfuException.ConnectionFailed("Failed to connect to DFU device ${device.address.anonymize()}")
}
bleConnection.profile(LegacyDfuUuids.SERVICE) { service ->
@@ -175,7 +178,7 @@ internal constructor(
throw LegacyDfuException.UnsupportedBootloader(version)
}
- Logger.i { "Legacy DFU: Connected and ready (${device.address})" }
+ Logger.i { "Legacy DFU: Connected and ready (${device.address.anonymize()})" }
}
}
diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt
index 73966139f9..a4e5eebfaa 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/ota/dfu/SecureDfuTransport.kt
@@ -47,6 +47,7 @@ import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.ble.DEFAULT_BLE_WRITE_VALUE_LENGTH
import org.meshtastic.core.ble.MeshtasticBleDevice
import org.meshtastic.core.common.util.safeCatching
+import org.meshtastic.core.model.util.anonymize
import org.meshtastic.feature.firmware.ota.calculateMacPlusOne
import org.meshtastic.feature.firmware.ota.receiveWithin
import org.meshtastic.feature.firmware.ota.scanForBleDevice
@@ -101,7 +102,7 @@ class SecureDfuTransport(
// Nordic Android DFU library does (BluetoothAdapter.getRemoteDevice(address).connectGatt). Scanning here is
// unreliable because the device may not have resumed advertising in the brief window after we released the
// mesh-service GATT.
- Logger.i { "DFU: Connecting to $address to trigger buttonless DFU..." }
+ Logger.i { "DFU: Connecting to ${address.anonymize()} to trigger buttonless DFU..." }
bleConnection.connectAndAwait(MeshtasticBleDevice(address), CONNECT_TIMEOUT)
// Try the Nordic Secure DFU service (FE59) first — used when the firmware is built with BLE_DFU_SECURE.
@@ -230,23 +231,25 @@ class SecureDfuTransport(
override suspend fun connectToDfuMode(): Result<Unit> = safeCatching {
val dfuAddress = calculateMacPlusOne(address)
val targetAddresses = setOf(address, dfuAddress)
- Logger.i { "DFU: Scanning for DFU mode device at $targetAddresses..." }
+ Logger.i { "DFU: Scanning for DFU mode device at ${targetAddresses.map { it.anonymize() }}..." }
val device =
scanForDevice { d -> d.address in targetAddresses }
?: throw DfuException.ConnectionFailed(
- "DFU mode device not found (tried $targetAddresses). If the device never rebooted into DFU mode, " +
+ "DFU mode device not found (tried ${targetAddresses.map {
+ it.anonymize()
+ }}). If the device never rebooted into DFU mode, " +
"a stale BLE bond may be blocking the trigger (Meshtastic BLEDfu requires " +
"SECMODE_ENC_WITH_MITM) — Forget+Re-pair the device in Android Bluetooth settings and retry.",
)
- Logger.i { "DFU: Found DFU mode device at ${device.address}, connecting..." }
+ Logger.i { "DFU: Found DFU mode device at ${device.address.anonymize()}, connecting..." }
bleConnection.connectionState.onEach { Logger.d { "DFU: Connection state → $it" } }.launchIn(transportScope)
val connected = bleConnection.connectAndAwait(device, CONNECT_TIMEOUT)
if (connected is BleConnectionState.Disconnected) {
- throw DfuException.ConnectionFailed("Failed to connect to DFU device ${device.address}")
+ throw DfuException.ConnectionFailed("Failed to connect to DFU device ${device.address.anonymize()}")
}
bleConnection.profile(SecureDfuUuids.SERVICE) { service ->
@@ -271,7 +274,7 @@ class SecureDfuTransport(
// Conservative settle after CCCD confirmation before issuing commands.
delay(SUBSCRIPTION_SETTLE)
- Logger.i { "DFU: Connected and ready (${device.address})" }
+ Logger.i { "DFU: Connected and ready (${device.address.anonymize()})" }
}
}
diff --git a/feature/firmware/src/jvmAndroidMain/kotlin/org/meshtastic/feature/firmware/ZipExtraction.kt b/feature/firmware/src/jvmAndroidMain/kotlin/org/meshtastic/feature/firmware/ZipExtraction.kt
new file mode 100644
index 0000000000..a1ec44cc6b
--- /dev/null
+++ b/feature/firmware/src/jvmAndroidMain/kotlin/org/meshtastic/feature/firmware/ZipExtraction.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.firmware
+
+import java.io.ByteArrayOutputStream
+import java.io.FilterInputStream
+import java.io.InputStream
+import java.util.zip.ZipInputStream
+
+/**
+ * Ceiling on a firmware archive as delivered. Real Meshtastic release zips are tens of MB, and the whole archive is
+ * streamed rather than buffered, so this only needs to reject something implausible.
+ */
+internal const val MAX_FIRMWARE_ZIP_BYTES = 128L * 1024 * 1024
+
+/**
+ * Ceiling on total inflated bytes held in memory across all entries.
+ *
+ * This is the bound that matters: a highly compressible archive is small on the wire and enormous once inflated, and
+ * every entry is retained in the returned map at once.
+ */
+internal const val MAX_FIRMWARE_UNCOMPRESSED_BYTES = 96L * 1024 * 1024
+
+/** Ceiling on entry count. A release archive holds a few hundred at most. */
+internal const val MAX_FIRMWARE_ZIP_ENTRIES = 4096
+
+private const val COPY_BUFFER_SIZE = 8192
+
+/**
+ * Fails once more than [limit] bytes have been pulled from [delegate].
+ *
+ * Bounds the *compressed* side. Without it the only limit on how much of an archive gets read is a declared size, and
+ * `getFileSize` reports 0 for a content provider that declines to answer — exactly the untrusted case. The
+ * inflated-byte budget alone doesn't cover this, because it constrains output rather than input.
+ */
+private class LimitedInputStream(delegate: InputStream, private val limit: Long) : FilterInputStream(delegate) {
+ private var consumed = 0L
+
+ private fun charge(bytes: Long) {
+ consumed += bytes
+ require(consumed <= limit) { "Firmware archive reads past the $limit-byte transfer limit" }
+ }
+
+ override fun read(): Int = super.read().also { if (it >= 0) charge(1) }
+
+ override fun read(b: ByteArray, off: Int, len: Int): Int =
+ super.read(b, off, len).also { if (it > 0) charge(it.toLong()) }
+}
+
+/**
+ * Reads at most [limit] bytes from [input], returning null if the source has more than that.
+ *
+ * Reads incrementally and stops as soon as the limit is passed, so the caller never materialises more than `limit +
+ * `[COPY_BUFFER_SIZE] bytes regardless of how large the source claims or turns out to be. Checking a size *after*
+ * reading an entry fully — the obvious-looking version of this — provides no protection at all, because the allocation
+ * that exhausts the heap has already happened by the time the check runs.
+ */
+internal fun readAtMost(input: InputStream, limit: Long): ByteArray? {
+ val out = ByteArrayOutputStream()
+ val buffer = ByteArray(COPY_BUFFER_SIZE)
+ var total = 0L
+ while (true) {
+ val read = input.read(buffer)
+ if (read < 0) return out.toByteArray()
+ total += read
+ if (total > limit) return null
+ out.write(buffer, 0, read)
+ }
+}
+
+/**
+ * Fully expands a zip from [input] into memory, keyed by entry name, refusing anything that would exceed the given
+ * bounds.
+ *
+ * Shared by the Android and desktop JVM [FirmwareFileHandler] implementations — a firmware archive is user- or
+ * network-supplied and every entry is held in memory simultaneously, so both need identical limits. The bounds are
+ * parameters so tests can drive them with small values instead of allocating hundreds of megabytes.
+ *
+ * Throws [IllegalArgumentException] when a bound is exceeded; callers surface that as a firmware-update error.
+ */
+internal fun extractZipEntriesBounded(
+ input: InputStream,
+ maxEntries: Int = MAX_FIRMWARE_ZIP_ENTRIES,
+ maxTotalBytes: Long = MAX_FIRMWARE_UNCOMPRESSED_BYTES,
+ maxCompressedBytes: Long = MAX_FIRMWARE_ZIP_BYTES,
+): Map<String, ByteArray> {
+ val entries = mutableMapOf<String, ByteArray>()
+ var remaining = maxTotalBytes
+ // Counted separately from `entries.size`: duplicate names collapse to one map key, so counting the map would let
+ // an archive of arbitrarily many same-named entries walk straight past the cap.
+ var entriesSeen = 0
+ // Wrapped here rather than at the call sites so neither handler can forget it.
+ ZipInputStream(LimitedInputStream(input, maxCompressedBytes)).use { zip ->
+ var entry = zip.nextEntry
+ while (entry != null) {
+ if (!entry.isDirectory) {
+ entriesSeen++
+ require(entriesSeen <= maxEntries) { "Firmware archive has more than $maxEntries entries" }
+ // Bounded by whatever budget is left, so the running total cannot be exceeded by a single entry.
+ val bytes =
+ readAtMost(zip, remaining)
+ ?: throw IllegalArgumentException("Firmware archive expands past the $maxTotalBytes-byte limit")
+ remaining -= bytes.size
+ entries[entry.name] = bytes
+ }
+ zip.closeEntry()
+ entry = zip.nextEntry
+ }
+ }
+ return entries
+}
diff --git a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt
index 82bbfb542c..8eb951938c 100644
--- a/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt
+++ b/feature/firmware/src/jvmMain/kotlin/org/meshtastic/feature/firmware/JvmFirmwareFileHandler.kt
@@ -181,21 +181,19 @@ class JvmFirmwareFileHandler(private val client: HttpClient) : FirmwareFileHandl
}
}
+ /**
+ * Fully expands [artifact] into memory, keyed by entry name.
+ *
+ * Shares [extractZipEntriesBounded] with the Android handler so the two cannot drift — they previously carried
+ * independent copies of this loop, and only one of them got bounded.
+ */
override suspend fun extractZipEntries(artifact: FirmwareArtifact): Map<String, ByteArray> =
withContext(ioDispatcher) {
- val entries = mutableMapOf<String, ByteArray>()
val file = artifact.toLocalFileOrNull() ?: throw IOException("Cannot resolve artifact: ${artifact.uri}")
- ZipInputStream(file.inputStream()).use { zip ->
- var entry = zip.nextEntry
- while (entry != null) {
- if (!entry.isDirectory) {
- entries[entry.name] = zip.readBytes()
- }
- zip.closeEntry()
- entry = zip.nextEntry
- }
+ require(file.length() <= MAX_FIRMWARE_ZIP_BYTES) {
+ "Firmware archive is ${file.length()} bytes, over the $MAX_FIRMWARE_ZIP_BYTES limit"
}
- entries
+ file.inputStream().use { extractZipEntriesBounded(it) }
}
override suspend fun copyToUri(source: FirmwareArtifact, destinationUri: CommonUri): Long =
diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ZipExtractionTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ZipExtractionTest.kt
new file mode 100644
index 0000000000..d288f2b719
--- /dev/null
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/ZipExtractionTest.kt
@@ -0,0 +1,232 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.firmware
+
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.FilterInputStream
+import java.io.InputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+import kotlin.random.Random
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class ZipExtractionTest {
+
+ /** Counts bytes actually pulled from the underlying source, so a test can assert reads stop early. */
+ private class CountingStream(delegate: InputStream) : FilterInputStream(delegate) {
+ var bytesRead = 0L
+ private set
+
+ override fun read(b: ByteArray, off: Int, len: Int): Int =
+ super.read(b, off, len).also { if (it > 0) bytesRead += it }
+
+ override fun read(): Int = super.read().also { if (it >= 0) bytesRead++ }
+ }
+
+ private fun zipOf(vararg entries: Pair<String, ByteArray>): ByteArray {
+ val out = ByteArrayOutputStream()
+ ZipOutputStream(out).use { zip ->
+ entries.forEach { (name, bytes) ->
+ zip.putNextEntry(ZipEntry(name))
+ zip.write(bytes)
+ zip.closeEntry()
+ }
+ }
+ return out.toByteArray()
+ }
+
+ // ---------- readAtMost: the ordering property ----------
+
+ @Test
+ fun `readAtMost stops reading once the limit is passed`() {
+ // This is the property the previous implementation got wrong: it inflated an entry fully and only then compared
+ // the size, so the allocation that exhausts the heap had already happened. Asserting on bytes actually pulled
+ // from the source is what distinguishes "checked before" from "checked after".
+ val tenMegabytes = ByteArray(10 * 1024 * 1024)
+ val counting = CountingStream(ByteArrayInputStream(tenMegabytes))
+
+ val result = readAtMost(counting, limit = 1024)
+
+ assertNull(result, "a source over the limit must be rejected")
+ assertTrue(
+ counting.bytesRead <= 1024 + 8192,
+ "must stop near the limit, but read ${counting.bytesRead} of ${tenMegabytes.size} bytes",
+ )
+ }
+
+ @Test
+ fun `readAtMost returns the content when it fits`() {
+ val payload = "firmware".encodeToByteArray()
+
+ val result = readAtMost(ByteArrayInputStream(payload), limit = 1024)
+
+ assertEquals("firmware", result?.decodeToString())
+ }
+
+ @Test
+ fun `readAtMost accepts a source exactly at the limit`() {
+ val payload = ByteArray(64) { 1 }
+
+ assertEquals(64, readAtMost(ByteArrayInputStream(payload), limit = 64)?.size)
+ assertNull(readAtMost(ByteArrayInputStream(payload), limit = 63))
+ }
+
+ // ---------- extractZipEntriesBounded ----------
+
+ @Test
+ fun `a normal archive extracts every entry`() {
+ val zip = zipOf("firmware.bin" to ByteArray(32) { 7 }, "manifest.json" to "{}".encodeToByteArray())
+
+ val entries = extractZipEntriesBounded(ByteArrayInputStream(zip))
+
+ assertEquals(setOf("firmware.bin", "manifest.json"), entries.keys)
+ assertEquals(32, entries["firmware.bin"]?.size)
+ }
+
+ @Test
+ fun `a compression bomb is refused without inflating it`() {
+ // 8 MiB of zeros compresses to a few KB. With a small budget this must abort during the entry, not after.
+ val bomb = zipOf("bomb.bin" to ByteArray(8 * 1024 * 1024))
+ val counting = CountingStream(ByteArrayInputStream(bomb))
+
+ assertFailsWith<IllegalArgumentException> { extractZipEntriesBounded(counting, maxTotalBytes = 4096) }
+ }
+
+ @Test
+ fun `the uncompressed budget is enforced across entries, not per entry`() {
+ // Three entries each under the budget but over it in total — a per-entry check would let this through.
+ val zip = zipOf("a" to ByteArray(2048) { 1 }, "b" to ByteArray(2048) { 2 }, "c" to ByteArray(2048) { 3 })
+
+ assertFailsWith<IllegalArgumentException> {
+ extractZipEntriesBounded(ByteArrayInputStream(zip), maxTotalBytes = 5000)
+ }
+ }
+
+ @Test
+ fun `too many entries is refused`() {
+ val many = Array(20) { "entry$it" to ByteArray(4) }
+ val zip = zipOf(*many)
+
+ assertFailsWith<IllegalArgumentException> {
+ extractZipEntriesBounded(ByteArrayInputStream(zip), maxEntries = 10)
+ }
+ }
+
+ /**
+ * Builds an archive containing [count] zero-byte STORED entries that all share [name].
+ *
+ * `ZipOutputStream` refuses to write a duplicate name, so this emits the local file headers directly — which is how
+ * a hostile archive would be produced anyway. `ZipInputStream` reads local headers sequentially and stops at the
+ * end-of-central-directory signature, so it yields all [count] entries.
+ */
+ private fun zipWithDuplicateNames(name: String, count: Int): ByteArray {
+ val out = ByteArrayOutputStream()
+ fun le16(v: Int) {
+ out.write(v and 0xFF)
+ out.write((v ushr 8) and 0xFF)
+ }
+ fun le32(v: Int) {
+ le16(v and 0xFFFF)
+ le16((v ushr 16) and 0xFFFF)
+ }
+ val nameBytes = name.encodeToByteArray()
+ repeat(count) {
+ le32(0x04034B50) // local file header signature
+ le16(20) // version needed
+ le16(0) // flags
+ le16(0) // method: stored
+ le16(0) // mod time
+ le16(0) // mod date
+ le32(0) // crc32 of empty data
+ le32(0) // compressed size
+ le32(0) // uncompressed size
+ le16(nameBytes.size)
+ le16(0) // extra length
+ out.write(nameBytes)
+ }
+ le32(0x06054B50) // end of central directory: stops the stream reader
+ repeat(18) { out.write(0) }
+ return out.toByteArray()
+ }
+
+ @Test
+ fun `the compressed side is bounded even when the declared size is unknown`() {
+ // getFileSize reports 0 for a provider that declines to answer, so the declared-size gate passes vacuously.
+ // Seeded-random content so deflate cannot shrink it — a regular pattern here compresses to a few hundred bytes
+ // and the bound would never be reached, making the test pass for the wrong reason.
+ val incompressible = Random(seed = 1234).nextBytes(256 * 1024)
+ val zip = zipOf("firmware.bin" to incompressible)
+
+ assertFailsWith<IllegalArgumentException> {
+ extractZipEntriesBounded(ByteArrayInputStream(zip), maxCompressedBytes = 4096)
+ }
+ }
+
+ @Test
+ fun `a normal archive is unaffected by the compressed bound`() {
+ val zip = zipOf("firmware.bin" to ByteArray(64) { 3 })
+
+ val entries = extractZipEntriesBounded(ByteArrayInputStream(zip), maxCompressedBytes = 1024 * 1024)
+
+ assertEquals(setOf("firmware.bin"), entries.keys)
+ }
+
+ @Test
+ fun `duplicate entry names cannot bypass the entry cap`() {
+ // The cap counted map keys, and duplicates collapse to one key — so an archive of arbitrarily many same-named
+ // entries walked straight past it while doing unbounded work.
+ val zip = zipWithDuplicateNames("same.bin", count = 50)
+
+ assertFailsWith<IllegalArgumentException> {
+ extractZipEntriesBounded(ByteArrayInputStream(zip), maxEntries = 10)
+ }
+ }
+
+ @Test
+ fun `the duplicate-name fixture really does yield repeated entries`() {
+ // Guards the fixture itself: if ZipInputStream collapsed or rejected these, the cap test above would pass for
+ // the wrong reason.
+ val zip = zipWithDuplicateNames("same.bin", count = 5)
+
+ val entries = extractZipEntriesBounded(ByteArrayInputStream(zip), maxEntries = 100)
+
+ assertEquals(setOf("same.bin"), entries.keys, "duplicates collapse to one key — that is the bug's premise")
+ }
+
+ @Test
+ fun `directory entries do not consume the entry budget`() {
+ val out = ByteArrayOutputStream()
+ ZipOutputStream(out).use { zip ->
+ repeat(20) {
+ zip.putNextEntry(ZipEntry("dir$it/"))
+ zip.closeEntry()
+ }
+ zip.putNextEntry(ZipEntry("firmware.bin"))
+ zip.write(ByteArray(8))
+ zip.closeEntry()
+ }
+
+ val entries = extractZipEntriesBounded(ByteArrayInputStream(out.toByteArray()), maxEntries = 2)
+
+ assertEquals(setOf("firmware.bin"), entries.keys)
+ }
+}
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
index e0be267cf4..557e6fdb65 100644
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt
@@ -76,6 +76,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.systemTimeZone
import org.meshtastic.core.model.geofence.GeofenceRadiusPresets
import org.meshtastic.core.model.isLocked
+import org.meshtastic.core.model.util.toCodePointString
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
@@ -190,7 +191,7 @@ fun EditWaypointDialog(
trailingIcon = {
IconButton(onClick = { showEmojiPickerView = true }) {
Text(
- text = String(Character.toChars(currentEmojiCodepoint)),
+ text = currentEmojiCodepoint.toCodePointString(),
modifier =
Modifier.background(MaterialTheme.colorScheme.surfaceVariant, CircleShape)
.padding(6.dp),
diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointInfoDialog.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointInfoDialog.kt
index 060bf820b0..b49dc7b14d 100644
--- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointInfoDialog.kt
+++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointInfoDialog.kt
@@ -31,6 +31,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.util.PUSHPIN_CODE_POINT
+import org.meshtastic.core.model.util.toCodePointString
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
@@ -62,12 +64,13 @@ fun WaypointInfoDialog(
onEdit: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
- val emoji = if (waypoint.icon == 0) PUSHPIN else waypoint.icon
+ // waypoint.icon is untrusted input; toCodePointString substitutes a fallback rather than throwing.
+ val emoji = if (waypoint.icon == 0) PUSHPIN_CODE_POINT.toCodePointString() else waypoint.icon.toCodePointString()
val title = waypoint.name.takeIf { it.isNotBlank() } ?: stringResource(Res.string.geofence)
AlertDialog(
onDismissRequest = onDismissRequest,
- title = { Text(text = "${String(Character.toChars(emoji))} $title", fontWeight = FontWeight.Bold) },
+ title = { Text(text = "$emoji $title", fontWeight = FontWeight.Bold) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
if (waypoint.description.isNotBlank()) {
@@ -108,5 +111,3 @@ fun WaypointInfoDialog(
modifier = modifier,
)
}
-
-private const val PUSHPIN = 0x1F4CD // 📍 Round Pushpin
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt
index 6cd9b3bde5..b4a99d1c74 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Debug.kt
@@ -48,6 +48,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -68,6 +69,8 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.debug_clear
import org.meshtastic.core.resources.debug_decoded_payload
+import org.meshtastic.core.resources.debug_logs_export
+import org.meshtastic.core.resources.debug_logs_export_warning
import org.meshtastic.core.resources.debug_panel
import org.meshtastic.core.resources.debug_store_logs_summary
import org.meshtastic.core.resources.debug_store_logs_title
@@ -81,6 +84,7 @@ import org.meshtastic.core.resources.log_retention_never
import org.meshtastic.core.ui.component.CopyIconButton
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -126,6 +130,19 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) {
}
// Prepare a document creator for exporting logs
val exportLogsLauncher = rememberLogExporter { buildString { formatLogsTo(this, viewModel.loadLogsForExport()) } }
+ // The export exists so users can attach it to a public issue, so state what it contains before writing it.
+ var showExportWarning by rememberSaveable { mutableStateOf(false) }
+ if (showExportWarning) {
+ MeshtasticResourceDialog(
+ titleRes = Res.string.debug_logs_export,
+ messageRes = Res.string.debug_logs_export_warning,
+ onConfirm = {
+ showExportWarning = false
+ exportLogsLauncher(timestampedExportName("meshtastic_debug"))
+ },
+ onDismiss = { showExportWarning = false },
+ )
+ }
var showSettings by remember { mutableStateOf(false) }
var selectedTab by remember { mutableIntStateOf(0) }
@@ -184,7 +201,7 @@ fun DebugScreen(onNavigateUp: () -> Unit, viewModel: DebugViewModel) {
logs = logs,
filterMode = filterMode,
onFilterModeChange = { filterMode = it },
- onExportLogs = { exportLogsLauncher(timestampedExportName("meshtastic_debug")) },
+ onExportLogs = { showExportWarning = true },
)
if (showSettings) {
DebugLogSettings(viewModel = viewModel)
@@ -318,20 +335,10 @@ private fun DebugItemHeader(log: UiMeshLog, searchText: String, isSelected: Bool
color = theme.onSurface,
),
)
- // Copy full log: message + decoded payload if present
- val fullLogText =
- remember(log.logMessage, log.decodedPayload) {
- buildString {
- append(log.logMessage)
- if (!log.decodedPayload.isNullOrBlank()) {
- append("\n\nDecoded Payload:\n{")
- append("\n")
- append(log.decodedPayload)
- append("\n}")
- }
- }
- }
- CopyIconButton(valueToCopy = fullLogText, modifier = Modifier.padding(start = 8.dp))
+ // Sanitised exactly like the file export — this text gets pasted into public issue trackers just as often, so
+ // it must not carry key material either. Marked sensitive so the OS does not surface it in a paste preview.
+ val fullLogText = remember(log.logMessage, log.decodedPayload) { formatLogEntryForCopy(log) }
+ CopyIconButton(valueToCopy = fullLogText, modifier = Modifier.padding(start = 8.dp), sensitive = true)
val dateAnnotatedString = rememberAnnotatedString(text = log.formattedReceivedDate, searchText = searchText)
Text(
text = dateAnnotatedString,
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogFormatter.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogFormatter.kt
index 6a23260497..64a70c0ae0 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogFormatter.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/LogFormatter.kt
@@ -24,13 +24,28 @@ import kotlinx.datetime.toLocalDateTime
import org.meshtastic.core.common.util.nowMillis
import kotlin.time.Instant.Companion.fromEpochMilliseconds
-internal val redactedKeys = listOf("session_passkey", "private_key", "admin_key")
+// `psk` is channel key material and reaches the debug log via set_channel / get_channel_response like any other field.
+internal val redactedKeys = listOf("session_passkey", "private_key", "admin_key", "psk")
// Matches `key: value`, `key=value`, `key=[hex=..]`, and one level of nested list like `admin_key=[[hex=..], [..]]`.
// The value alternation is ordered bracket-list | quoted | bare-token so the widest match wins.
private val REDACT_REGEX =
Regex("(${redactedKeys.joinToString("|")})\\s*([:=])\\s*(\\[(?:[^\\[\\]]|\\[[^\\]]*\\])*\\]|\"[^\"]*\"|\\S+)")
+/**
+ * Matches an undecoded binary `payload` field in a proto `toString()`, in every shape okio can render it.
+ *
+ * Structured redaction alone is not enough: a `set_channel` AdminMessage carries the channel PSK inside its serialised
+ * payload, so the raw bytes have to go too. okio has three renderings for a `ByteString` and the byte count selects
+ * between them — `[hex=..]` at 64 bytes or fewer, `[size=N hex=..…]` above that. Matching only the first leaves the key
+ * intact for every payload large enough to matter, which is most of them.
+ *
+ * `[text=..]` is deliberately NOT matched here: that rendering only occurs for a valid-UTF-8 payload, i.e. message
+ * content rather than key material, and its content is unbounded and unescaped so it cannot be delimited reliably. The
+ * export warning discloses that message text is present.
+ */
+private val PAYLOAD_HEX_REGEX = Regex("""(payload)\s*([:=])\s*\[(?:size=\d+ )?hex=[0-9a-fA-F]*…?]""")
+
/** Builds a collision-free export file name, e.g. `meshtastic_logcat_20260701_143312.txt`. */
internal fun timestampedExportName(prefix: String): String {
val format =
@@ -54,7 +69,8 @@ internal fun timestampedExportName(prefix: String): String {
internal fun formatLogsTo(out: Appendable, logs: List<DebugViewModel.UiMeshLog>) {
logs.forEach { log ->
out.append("${log.formattedReceivedDate} [${log.messageType}]\n")
- out.append(log.logMessage)
+ // logMessage is the annotated proto toString, so it carries both structured fields and the raw payload bytes.
+ out.append(sanitizeForExport(log.logMessage))
val decodedPayload = log.decodedPayload
if (!decodedPayload.isNullOrBlank()) {
appendRedactedPayload(out, decodedPayload)
@@ -62,6 +78,23 @@ internal fun formatLogsTo(out: Appendable, logs: List<DebugViewModel.UiMeshLog>)
}
}
+/**
+ * Renders one log entry for the per-entry copy action, sanitised the same way the file export is.
+ *
+ * Extracted from the composable so the sanitisation is covered by a test rather than by inspection.
+ */
+internal fun formatLogEntryForCopy(log: DebugViewModel.UiMeshLog): String = sanitizeForExport(
+ buildString {
+ append(log.logMessage)
+ if (!log.decodedPayload.isNullOrBlank()) {
+ append("\n\nDecoded Payload:\n{")
+ append("\n")
+ append(log.decodedPayload)
+ append("\n}")
+ }
+ },
+)
+
/**
* Appends captured Android logcat to [out] under a header, redacting sensitive keys line-by-line. The app should never
* log keys/PII, so this is defence-in-depth for a file the user is about to share on a public issue tracker.
@@ -69,7 +102,7 @@ internal fun formatLogsTo(out: Appendable, logs: List<DebugViewModel.UiMeshLog>)
internal fun appendLogcat(out: Appendable, logcat: String) {
out.append("\n===== App Logcat =====\n")
logcat.lineSequence().forEach { line ->
- out.append(redactLine(line))
+ out.append(sanitizeForExport(line))
out.append("\n")
}
}
@@ -77,7 +110,7 @@ internal fun appendLogcat(out: Appendable, logcat: String) {
private fun appendRedactedPayload(out: Appendable, payload: String) {
out.append("\n\nDecoded Payload:\n{\n")
payload.lineSequence().forEach { line ->
- out.append(redactLine(line))
+ out.append(sanitizeForExport(line))
out.append("\n")
}
out.append("}\n\n")
@@ -86,5 +119,17 @@ private fun appendRedactedPayload(out: Appendable, payload: String) {
/** Redacts sensitive-key values in every line of [text]; used to redact logcat both on screen and on export. */
internal fun redactText(text: String): String = text.lineSequence().joinToString("\n") { redactLine(it) }
+/**
+ * Full sanitisation for text leaving the app — the file export and the per-entry copy action both use this.
+ *
+ * Sensitive-key redaction plus raw payload-byte suppression. Use this rather than [redactLine] anywhere log text is
+ * written to a file or the clipboard, since both end up pasted into public issue trackers.
+ */
+internal fun sanitizeForExport(text: String): String =
+ text.lineSequence().joinToString("\n") { line -> suppressPayloadBytes(redactLine(line)) }
+
private fun redactLine(line: String): String =
REDACT_REGEX.replace(line) { "${it.groupValues[1]}${it.groupValues[2]}<redacted>" }
+
+private fun suppressPayloadBytes(line: String): String =
+ PAYLOAD_HEX_REGEX.replace(line) { "${it.groupValues[1]}${it.groupValues[2]}<suppressed>" }
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Logcat.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Logcat.kt
index f779290370..13f32885d3 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Logcat.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/Logcat.kt
@@ -42,6 +42,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -58,6 +59,8 @@ import org.meshtastic.core.resources.debug_default_search
import org.meshtastic.core.resources.debug_logcat_empty
import org.meshtastic.core.resources.debug_logcat_refresh
import org.meshtastic.core.resources.debug_logs_export
+import org.meshtastic.core.resources.debug_logs_export_warning
+import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.icon.FileDownload
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
@@ -113,6 +116,19 @@ fun LogcatContent(modifier: Modifier = Modifier) {
val listState = rememberLazyListState()
val export = rememberLogExporter { buildString { appendLogcat(this, raw.orEmpty()) } }
+ // Same warning as the packet-log export: this file is meant to be attached to public issue trackers.
+ var showExportWarning by rememberSaveable { mutableStateOf(false) }
+ if (showExportWarning) {
+ MeshtasticResourceDialog(
+ titleRes = Res.string.debug_logs_export,
+ messageRes = Res.string.debug_logs_export_warning,
+ onConfirm = {
+ showExportWarning = false
+ export(timestampedExportName("meshtastic_logcat"))
+ },
+ onDismiss = { showExportWarning = false },
+ )
+ }
fun refresh() = scope.launch { raw = withContext(ioDispatcher) { captureAppLogcat() } }
LaunchedEffect(Unit) { refresh() }
@@ -146,7 +162,7 @@ fun LogcatContent(modifier: Modifier = Modifier) {
)
}
Box(modifier = Modifier.weight(1f))
- IconButton(onClick = { export(timestampedExportName("meshtastic_logcat")) }) {
+ IconButton(onClick = { showExportWarning = true }) {
Icon(MeshtasticIcons.FileDownload, contentDescription = stringResource(Res.string.debug_logs_export))
}
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
index a338444691..d1d0d1361e 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
@@ -36,6 +36,7 @@ import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Capabilities
import org.meshtastic.core.model.util.encodeToString
+import org.meshtastic.core.model.util.platformRandomBytes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.admin_key
import org.meshtastic.core.resources.admin_keys
@@ -68,7 +69,6 @@ import org.meshtastic.feature.settings.lockdown.LockdownModeSetting
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.RebootBehavior
import org.meshtastic.proto.Config
-import kotlin.random.Random
@Composable
expect fun SecurityKeyBackupActions(
@@ -157,7 +157,9 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
formState.value = formState.value.copy(private_key = it)
}
},
- trailingIcon = { CopyIconButton(valueToCopy = formState.value.private_key.encodeToString()) },
+ trailingIcon = {
+ CopyIconButton(valueToCopy = formState.value.private_key.encodeToString(), sensitive = true)
+ },
)
HorizontalDivider()
NodeActionButton(
@@ -258,8 +260,9 @@ fun PrivateKeyRegenerateDialog(
titleRes = Res.string.regenerate_private_key,
messageRes = Res.string.regenerate_keys_confirmation,
onConfirm = {
- // Generate a random "f" value
- val f = ByteArray(32).apply { Random.nextBytes(this) }
+ // Generate a random "f" value. This is long-term key material, so it must come from the platform CSPRNG
+ // — kotlin.random.Random is a small-state, clock-seeded PRNG and is not acceptable here.
+ val f = platformRandomBytes(PRIVATE_KEY_SIZE)
// Adjust the value to make it valid as an "s" value for eval().
// According to the specification we need to mask off the 3
// right-most bits of f[0], mask off the left-most bit of f[31],
@@ -273,4 +276,7 @@ fun PrivateKeyRegenerateDialog(
}
}
+/** X25519 private key length in bytes. */
+private const val PRIVATE_KEY_SIZE = 32
+
private const val SECONDS_PER_MINUTE = 60
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogFormatterTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogFormatterTest.kt
index 6182fb2d91..360dba742a 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogFormatterTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/LogFormatterTest.kt
@@ -22,6 +22,75 @@ import kotlin.test.assertTrue
class LogFormatterTest {
+ /** A 32-byte channel PSK as it appears in hex inside a serialised AdminMessage. */
+ private val pskHex = "c9ee13385d82a7ccf1163b6085aacff4193e6388add2f71c41668bb0d5fa1f44"
+
+ private fun log(logMessage: String, decodedPayload: String? = null) = DebugViewModel.UiMeshLog(
+ uuid = "1",
+ messageType = "Admin",
+ formattedReceivedDate = "2026-03-25",
+ logMessage = logMessage,
+ decodedPayload = decodedPayload,
+ )
+
+ private fun export(vararg logs: DebugViewModel.UiMeshLog): String =
+ StringBuilder().also { formatLogsTo(it, logs.toList()) }.toString()
+
+ @Test
+ fun `export suppresses payload hex in okio's truncated form`() {
+ // okio renders a ByteString longer than 64 bytes as `[size=N hex=<first 64 bytes>…]`, NOT `[hex=..]`. A real
+ // set_channel / get_channel_response AdminMessage exceeds 64 bytes and the PSK lands inside that prefix, so
+ // this is the shape that actually carries the key on the export path.
+ val result = export(log("Data{portnum=ADMIN_APP, payload=[size=75 hex=1a3f080112391220$pskHex…]}"))
+
+ assertFalse(result.contains(pskHex), "the channel key must not survive export in the truncated form")
+ assertTrue(result.contains("payload=<suppressed>"))
+ assertTrue(result.contains("portnum=ADMIN_APP"), "surrounding structure is kept")
+ }
+
+ @Test
+ fun `export suppresses payload hex regardless of the byte count`() {
+ // Guard the boundary between okio's two binary renderings rather than one example length.
+ listOf(
+ "payload=[hex=$pskHex]",
+ "payload=[size=32 hex=$pskHex]",
+ "payload=[size=140 hex=$pskHex…]",
+ "payload=[size=1024 hex=$pskHex…]",
+ )
+ .forEach { field ->
+ val result = export(log("Data{portnum=ADMIN_APP, $field, want_response=true}"))
+ assertFalse(result.contains(pskHex), "key survived export for '$field'")
+ assertTrue(result.contains("want_response=true"), "later fields preserved for '$field'")
+ }
+ }
+
+ @Test
+ fun `the per-entry copy action is sanitised like the export`() {
+ // The copy button sits next to the export and its output gets pasted into the same public issue trackers, so it
+ // must not be a way around the redaction. Binds the real code path, not just the helper.
+ val copied =
+ formatLogEntryForCopy(
+ log(
+ logMessage = "Data{portnum=ADMIN_APP, payload=[size=75 hex=1a3f080112391220$pskHex…]}",
+ decodedPayload = "settings=ChannelSettings{psk=[hex=$pskHex], name=LongFast}",
+ ),
+ )
+
+ assertFalse(copied.contains(pskHex), "the channel key must not reach the clipboard")
+ assertTrue(copied.contains("payload=<suppressed>"))
+ assertTrue(copied.contains("psk=<redacted>"))
+ assertTrue(copied.contains("name=LongFast"), "non-sensitive fields are kept")
+ }
+
+ @Test
+ fun `structured psk redaction covers okio's long and short forms`() {
+ listOf("psk=[hex=$pskHex]", "psk=[size=140 hex=$pskHex…]").forEach { field ->
+ val result = export(log("AdminMessage", decodedPayload = "settings=ChannelSettings{$field, name=X}"))
+ assertFalse(result.contains(pskHex), "key survived for '$field'")
+ assertTrue(result.contains("name=X"))
+ }
+ }
+
@Test
fun `formatLogsTo formats and redacts correctly`() {
val logs =
diff --git a/feature/wifi-provision/build.gradle.kts b/feature/wifi-provision/build.gradle.kts
index 30347d66c3..cc815b189c 100644
--- a/feature/wifi-provision/build.gradle.kts
+++ b/feature/wifi-provision/build.gradle.kts
@@ -30,6 +30,8 @@ kotlin {
implementation(projects.core.ble)
implementation(projects.core.common)
implementation(projects.core.di)
+ // For Any?.anonymize(), used to keep BLE addresses out of remote logs.
+ implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.resources)
implementation(projects.core.ui)
diff --git a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt
index f78c7323ce..eb1f50c7e4 100644
--- a/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt
+++ b/feature/wifi-provision/src/commonMain/kotlin/org/meshtastic/feature/wifiprovision/domain/NymeaWifiService.kt
@@ -36,6 +36,7 @@ import org.meshtastic.core.ble.BleConnectionState
import org.meshtastic.core.ble.BleScanner
import org.meshtastic.core.ble.BleWriteType
import org.meshtastic.core.common.util.safeCatching
+import org.meshtastic.core.model.util.anonymize
import org.meshtastic.feature.wifiprovision.NymeaBleConstants
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT
import org.meshtastic.feature.wifiprovision.NymeaBleConstants.CMD_CONNECT_HIDDEN
@@ -91,7 +92,7 @@ class NymeaWifiService(
* @throws IllegalStateException if no device is found within [SCAN_TIMEOUT].
*/
suspend fun connect(address: String? = null): Result<String> = safeCatching {
- Logger.i { "$TAG: Scanning for nymea-networkmanager device (address=$address)…" }
+ Logger.i { "$TAG: Scanning for nymea-networkmanager device (address=${address.anonymize()})…" }
val device =
withTimeout(SCAN_TIMEOUT) {
@@ -99,10 +100,12 @@ class NymeaWifiService(
}
val deviceName = device.name ?: device.address
- Logger.i { "$TAG: Found device: ${device.name} @ ${device.address}" }
+ Logger.i { "$TAG: Found device @ ${device.address.anonymize()}" }
val state = bleConnection.connectAndAwait(device, SCAN_TIMEOUT)
- check(state is BleConnectionState.Connected) { "Failed to connect to ${device.address} — final state: $state" }
+ check(state is BleConnectionState.Connected) {
+ "Failed to connect to ${device.address.anonymize()} — final state: $state"
+ }
Logger.i { "$TAG: Connected. Discovering wireless service…" }
Served by rngit 1.5.0 - Generated in 0.75s